Files
FlexLove/FlexLove.lua
Michael Freno c77d93fdee almost
2025-11-05 16:39:43 -05:00

665 lines
20 KiB
Lua

--[[
FlexLove - UI Library for LÖVE Framework 'based' on flexbox
VERSION: 1.0.0
LICENSE: MIT
For full documentation, see README.md
]]
local modulePath = (...):match("(.-)[^%.]+$") -- Get the module path prefix (e.g., "libs." or "")
local function req(name)
return require(modulePath .. "modules." .. name)
end
-- internals
local Blur = req("Blur")
local ImageCache = req("ImageCache")
local ImageDataReader = req("ImageDataReader")
local ImageRenderer = req("ImageRenderer")
local ImageScaler = req("ImageScaler")
local NinePatchParser = req("NinePatchParser")
local utils = req("utils")
local Units = req("Units")
local GuiState = req("GuiState")
local ImmediateModeState = req("ImmediateModeState")
local StateManager = req("StateManager")
-- externals
---@type Theme
local Theme = req("Theme")
---@type Animation
local Animation = req("Animation")
---@type Color
local Color = req("Color")
---@type Element
local Element = req("Element")
local enums = utils.enums
local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, TextAlign, AlignSelf, JustifySelf, FlexWrap =
enums.Positioning,
enums.FlexDirection,
enums.JustifyContent,
enums.AlignContent,
enums.AlignItems,
enums.TextAlign,
enums.AlignSelf,
enums.JustifySelf,
enums.FlexWrap
-- ====================
-- Top level GUI manager
-- ====================
---@class Gui
local Gui = GuiState
--- Initialize FlexLove with configuration
---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition, immediateMode?: boolean, stateRetentionFrames?: number, maxStateEntries?: number}
function Gui.init(config)
if config.baseScale then
Gui.baseScale = {
width = config.baseScale.width or 1920,
height = config.baseScale.height or 1080,
}
local currentWidth, currentHeight = Units.getViewport()
Gui.scaleFactors.x = currentWidth / Gui.baseScale.width
Gui.scaleFactors.y = currentHeight / Gui.baseScale.height
end
if config.theme then
local success, err = pcall(function()
if type(config.theme) == "string" then
Theme.load(config.theme)
Theme.setActive(config.theme)
Gui.defaultTheme = config.theme
elseif type(config.theme) == "table" then
local theme = Theme.new(config.theme)
Theme.setActive(theme)
Gui.defaultTheme = theme.name
end
end)
if not success then
print("[FlexLove] Failed to load theme: " .. tostring(err))
end
end
-- Initialize immediate mode if requested
if config.immediateMode then
Gui._immediateMode = true
Gui._immediateModeState = ImmediateModeState
-- Configure state management
if config.stateRetentionFrames or config.maxStateEntries then
ImmediateModeState.configure({
stateRetentionFrames = config.stateRetentionFrames,
maxStateEntries = config.maxStateEntries,
})
end
else
-- Explicitly disable immediate mode if not requested
Gui._immediateMode = false
Gui._immediateModeState = nil
end
end
function Gui.resize()
local newWidth, newHeight = love.window.getMode()
if Gui.baseScale then
Gui.scaleFactors.x = newWidth / Gui.baseScale.width
Gui.scaleFactors.y = newHeight / Gui.baseScale.height
end
Blur.clearCache()
Gui._gameCanvas = nil
Gui._backdropCanvas = nil
Gui._canvasDimensions = { width = 0, height = 0 }
for _, win in ipairs(Gui.topElements) do
win:resize(newWidth, newHeight)
end
end
--- Begin a new immediate mode frame
function Gui.beginFrame()
if not Gui._immediateMode then
return
end
-- Increment frame counter
Gui._frameNumber = Gui._frameNumber + 1
ImmediateModeState.incrementFrame()
StateManager.incrementFrame()
-- Clear current frame elements
Gui._currentFrameElements = {}
-- Clear top elements (they will be recreated this frame)
Gui.topElements = {}
end
--- End the current immediate mode frame
function Gui.endFrame()
if not Gui._immediateMode then
return
end
-- Save state back for all elements created this frame
for _, element in ipairs(Gui._currentFrameElements) do
if element.id and element.id ~= "" then
local state = ImmediateModeState.getState(element.id, {})
-- Save stateful properties back to persistent state
state._pressed = element._pressed
state._lastClickTime = element._lastClickTime
state._lastClickButton = element._lastClickButton
state._clickCount = element._clickCount
state._dragStartX = element._dragStartX
state._dragStartY = element._dragStartY
state._lastMouseX = element._lastMouseX
state._lastMouseY = element._lastMouseY
state._hovered = element._hovered
state._focused = element._focused
state._cursorPosition = element._cursorPosition
state._selectionStart = element._selectionStart
state._selectionEnd = element._selectionEnd
state._textBuffer = element._textBuffer
state._scrollX = element._scrollX
state._scrollY = element._scrollY
state._scrollbarDragging = element._scrollbarDragging
state._hoveredScrollbar = element._hoveredScrollbar
state._scrollbarDragOffset = element._scrollbarDragOffset
ImmediateModeState.setState(element.id, state)
end
end
-- Cleanup stale states
ImmediateModeState.cleanup()
StateManager.cleanup()
-- Force cleanup if we have too many states
ImmediateModeState.forceCleanupIfNeeded()
StateManager.forceCleanupIfNeeded()
end
-- Canvas cache for game rendering
Gui._gameCanvas = nil
Gui._backdropCanvas = nil
Gui._canvasDimensions = { width = 0, height = 0 }
---@param gameDrawFunc function|nil
---@param postDrawFunc function|nil
function Gui.draw(gameDrawFunc, postDrawFunc)
local outerCanvas = love.graphics.getCanvas()
local gameCanvas = nil
if type(gameDrawFunc) == "function" then
local width, height = love.graphics.getDimensions()
if not Gui._gameCanvas or Gui._canvasDimensions.width ~= width or Gui._canvasDimensions.height ~= height then
Gui._gameCanvas = love.graphics.newCanvas(width, height)
Gui._backdropCanvas = love.graphics.newCanvas(width, height)
Gui._canvasDimensions.width = width
Gui._canvasDimensions.height = height
end
gameCanvas = Gui._gameCanvas
love.graphics.setCanvas(gameCanvas)
love.graphics.clear()
gameDrawFunc()
love.graphics.setCanvas(outerCanvas)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(gameCanvas, 0, 0)
end
table.sort(Gui.topElements, function(a, b)
return a.z < b.z
end)
local function hasBackdropBlur(element)
if element.backdropBlur and element.backdropBlur.intensity > 0 then
return true
end
for _, child in ipairs(element.children) do
if hasBackdropBlur(child) then
return true
end
end
return false
end
local needsBackdropCanvas = false
for _, win in ipairs(Gui.topElements) do
if hasBackdropBlur(win) then
needsBackdropCanvas = true
break
end
end
if needsBackdropCanvas and gameCanvas then
local backdropCanvas = Gui._backdropCanvas
local prevColor = { love.graphics.getColor() }
love.graphics.setCanvas(backdropCanvas)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(gameCanvas, 0, 0)
love.graphics.setCanvas(outerCanvas)
love.graphics.setColor(unpack(prevColor))
for _, win in ipairs(Gui.topElements) do
win:draw(backdropCanvas)
love.graphics.setCanvas(backdropCanvas)
love.graphics.setColor(1, 1, 1, 1)
win:draw(nil)
love.graphics.setCanvas(outerCanvas)
end
else
for _, win in ipairs(Gui.topElements) do
win:draw(nil)
end
end
if type(postDrawFunc) == "function" then
postDrawFunc()
end
love.graphics.setCanvas(outerCanvas)
end
--- Check if element is an ancestor of target
---@param element Element
---@param target Element
---@return boolean
local function isAncestor(element, target)
local current = target.parent
while current do
if current == element then
return true
end
current = current.parent
end
return false
end
--- Find the topmost element at given coordinates
---@param x number
---@param y number
---@return Element?
function Gui.getElementAtPosition(x, y)
local candidates = {}
local blockingElements = {}
local function collectHits(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Adjust mouse position by accumulated scroll offset for hit testing
local adjustedX = x + scrollOffsetX
local adjustedY = y + scrollOffsetY
if adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh then
-- Collect interactive elements (those with callbacks)
if element.callback and not element.disabled then
table.insert(candidates, element)
end
-- Collect all visible elements for input blocking
-- Elements with opacity > 0 block input to elements below them
if element.opacity > 0 then
table.insert(blockingElements, element)
end
-- Check if this element has scrollable overflow
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
)
-- Accumulate scroll offset for children if this element has overflow clipping
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if hasScrollableOverflow then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectHits(child, childScrollOffsetX, childScrollOffsetY)
end
end
end
for _, element in ipairs(Gui.topElements) do
collectHits(element)
end
-- Sort both lists by z-index (highest first)
table.sort(candidates, function(a, b)
return a.z > b.z
end)
table.sort(blockingElements, function(a, b)
return a.z > b.z
end)
-- If we have interactive elements, return the topmost one
-- But only if there's no blocking element with higher z-index (that isn't an ancestor)
if #candidates > 0 then
local topCandidate = candidates[1]
-- Check if any blocking element would prevent this interaction
if #blockingElements > 0 then
local topBlocker = blockingElements[1]
-- If the top blocker has higher z-index than the top candidate,
-- and the blocker is NOT an ancestor of the candidate,
-- return the blocker (even though it has no callback, it blocks input)
if topBlocker.z > topCandidate.z and not isAncestor(topBlocker, topCandidate) then
return topBlocker
end
end
return topCandidate
end
-- No interactive elements, but return topmost blocking element if any
-- This prevents clicks from passing through non-interactive overlays
return blockingElements[1]
end
function Gui.update(dt)
local mx, my = love.mouse.getPosition()
local topElement = Gui.getElementAtPosition(mx, my)
Gui._activeEventElement = topElement
for _, win in ipairs(Gui.topElements) do
win:update(dt)
end
Gui._activeEventElement = nil
end
--- Forward text input to focused element
---@param text string
function Gui.textinput(text)
if Gui._focusedElement then
Gui._focusedElement:textinput(text)
end
end
--- Forward key press to focused element
---@param key string
---@param scancode string
---@param isrepeat boolean
function Gui.keypressed(key, scancode, isrepeat)
if Gui._focusedElement then
Gui._focusedElement:keypressed(key, scancode, isrepeat)
end
end
--- Handle mouse wheel scrolling
function Gui.wheelmoved(x, y)
local mx, my = love.mouse.getPosition()
local function findScrollableAtPosition(elements, mx, my)
for i = #elements, 1, -1 do
local element = elements[i]
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
if #element.children > 0 then
local childResult = findScrollableAtPosition(element.children, mx, my)
if childResult then
return childResult
end
end
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") and (element._overflowX or element._overflowY) then
return element
end
end
end
return nil
end
-- In immediate mode, use current frame elements; in retained mode, use topElements
local elements = Gui._immediateMode and Gui._currentFrameElements or Gui.topElements
local scrollableElement = findScrollableAtPosition(elements, mx, my)
if scrollableElement then
scrollableElement:_handleWheelScroll(x, y)
end
end
--- Destroy all elements and their children
function Gui.destroy()
for _, win in ipairs(Gui.topElements) do
win:destroy()
end
Gui.topElements = {}
Gui.baseScale = nil
Gui.scaleFactors = { x = 1.0, y = 1.0 }
Gui._cachedViewport = { width = 0, height = 0 }
Gui._gameCanvas = nil
Gui._backdropCanvas = nil
Gui._canvasDimensions = { width = 0, height = 0 }
Gui._focusedElement = nil
end
-- ====================
-- Immediate Mode API
-- ====================
--- Create a new element (supports both immediate and retained mode)
---@param props table
---@return Element
function Gui.new(props)
props = props or {}
-- If not in immediate mode, use standard Element.new
if not Gui._immediateMode then
return Element.new(props)
end
-- Immediate mode: generate ID if not provided
if not props.id then
props.id = ImmediateModeState.generateID(props)
end
-- Get or create state for this element
local state = ImmediateModeState.getState(props.id, {})
-- Mark state as used this frame
ImmediateModeState.markStateUsed(props.id)
-- Create the element
local element = Element.new(props)
-- Bind persistent state to element (ImmediateModeState)
-- Copy stateful properties from persistent state
element._pressed = state._pressed or {}
element._lastClickTime = state._lastClickTime
element._lastClickButton = state._lastClickButton
element._clickCount = state._clickCount or 0
element._dragStartX = state._dragStartX or element._dragStartX or {}
element._dragStartY = state._dragStartY or element._dragStartY or {}
element._lastMouseX = state._lastMouseX or element._lastMouseX or {}
element._lastMouseY = state._lastMouseY or element._lastMouseY or {}
element._hovered = state._hovered
element._focused = state._focused
element._cursorPosition = state._cursorPosition
element._selectionStart = state._selectionStart
element._selectionEnd = state._selectionEnd
element._textBuffer = state._textBuffer or element.text or ""
element._scrollX = state._scrollX or element._scrollX or 0
element._scrollY = state._scrollY or element._scrollY or 0
element._scrollbarDragging = state._scrollbarDragging or false
element._hoveredScrollbar = state._hoveredScrollbar
element._scrollbarDragOffset = state._scrollbarDragOffset or 0
-- Bind element to StateManager for interactive states
-- Use the same ID for StateManager so state persists across frames
element._stateId = props.id
-- Load interactive state from StateManager
local interactiveState = StateManager.getState(props.id)
element._scrollbarHoveredVertical = interactiveState.scrollbarHoveredVertical
element._scrollbarHoveredHorizontal = interactiveState.scrollbarHoveredHorizontal
element._scrollbarDragging = interactiveState.scrollbarDragging
element._hoveredScrollbar = interactiveState.hoveredScrollbar
element._scrollbarDragOffset = interactiveState.scrollbarDragOffset or 0
-- Set initial theme state based on StateManager state
-- This will be updated in Element:update() but we need an initial value
if element.themeComponent then
if element.disabled or interactiveState.disabled then
element._themeState = "disabled"
elseif element.active or interactiveState.active then
element._themeState = "active"
elseif interactiveState.pressed then
element._themeState = "pressed"
elseif interactiveState.hover then
element._themeState = "hover"
else
element._themeState = "normal"
end
end
-- Store element in current frame tracking
table.insert(Gui._currentFrameElements, element)
-- Save state back at end of frame (we'll do this in endFrame)
-- For now, we need to update the state when properties change
-- This is a simplified approach - a full implementation would use
-- a more sophisticated state synchronization mechanism
return element
end
--- Get state count (for debugging)
---@return number
function Gui.getStateCount()
if not Gui._immediateMode then
return 0
end
return ImmediateModeState.getStateCount()
end
--- Clear state for a specific element ID
---@param id string
function Gui.clearState(id)
if not Gui._immediateMode then
return
end
ImmediateModeState.clearState(id)
end
--- Clear all immediate mode states
function Gui.clearAllStates()
if not Gui._immediateMode then
return
end
ImmediateModeState.clearAllStates()
end
--- Get state statistics (for debugging)
---@return table
function Gui.getStateStats()
if not Gui._immediateMode then
return { stateCount = 0, frameNumber = 0 }
end
return ImmediateModeState.getStats()
end
--- Helper function: Create a button with default styling
---@param props table
---@return Element
function Gui.button(props)
props = props or {}
props.themeComponent = props.themeComponent or "button"
return Gui.new(props)
end
--- Helper function: Create a panel/container
---@param props table
---@return Element
function Gui.panel(props)
props = props or {}
return Gui.new(props)
end
--- Helper function: Create a text label
---@param props table
---@return Element
function Gui.text(props)
props = props or {}
return Gui.new(props)
end
--- Helper function: Create an input field
---@param props table
---@return Element
function Gui.input(props)
props = props or {}
props.editable = true
return Gui.new(props)
end
-- Export original Element.new for direct access if needed
Gui.Element = Element
Gui.Animation = Animation
Gui.Theme = Theme
Gui.ImageCache = ImageCache
Gui.ImageDataReader = ImageDataReader
Gui.ImageRenderer = ImageRenderer
Gui.ImageScaler = ImageScaler
Gui.NinePatchParser = NinePatchParser
Gui.ImmediateModeState = ImmediateModeState
return {
Gui = Gui,
Element = Element,
Color = Color,
Theme = Theme,
Positioning = Positioning,
FlexDirection = FlexDirection,
JustifyContent = JustifyContent,
AlignContent = AlignContent,
AlignItems = AlignItems,
TextAlign = TextAlign,
AlignSelf = AlignSelf,
JustifySelf = JustifySelf,
FlexWrap = FlexWrap,
enums = enums,
-- generally should not be used directly, exported for testing, mainly
ImageCache = ImageCache,
ImageRenderer = ImageRenderer,
ImageScaler = ImageScaler,
}