479 lines
14 KiB
Lua
479 lines
14 KiB
Lua
--[[
|
|
Renderer.lua - Rendering module for FlexLove Element
|
|
Handles all visual rendering including backgrounds, borders, images, themes, and effects
|
|
]]
|
|
|
|
-- Setup module path for relative requires
|
|
local modulePath = (...):match("(.-)[^%.]+$")
|
|
local function req(name)
|
|
return require(modulePath .. name)
|
|
end
|
|
|
|
-- Module dependencies
|
|
local Color = req("Color")
|
|
local RoundedRect = req("RoundedRect")
|
|
local NinePatch = req("NinePatch")
|
|
local ImageRenderer = req("ImageRenderer")
|
|
local Blur = req("Blur")
|
|
local Theme = req("Theme")
|
|
local utils = req("utils")
|
|
|
|
-- Extract utilities
|
|
local FONT_CACHE = utils.FONT_CACHE
|
|
|
|
-- ====================
|
|
-- Renderer Class
|
|
-- ====================
|
|
|
|
---@class Renderer
|
|
---@field element Element -- Reference to parent element
|
|
---@field backgroundColor Color -- Background color
|
|
---@field borderColor Color -- Border color
|
|
---@field opacity number -- Opacity (0-1)
|
|
---@field border {top:boolean, right:boolean, bottom:boolean, left:boolean} -- Border sides
|
|
---@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} -- Corner radii
|
|
---@field theme string? -- Theme name
|
|
---@field themeComponent string? -- Theme component name
|
|
---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled)
|
|
---@field imagePath string? -- Path to image file
|
|
---@field image love.Image? -- Image object
|
|
---@field _loadedImage love.Image? -- Cached loaded image
|
|
---@field objectFit string -- Image fit mode
|
|
---@field objectPosition string -- Image position
|
|
---@field imageOpacity number -- Image opacity
|
|
---@field contentBlur table? -- Content blur settings
|
|
---@field backdropBlur table? -- Backdrop blur settings
|
|
---@field _blurInstance table? -- Cached blur instance
|
|
---@field scaleCorners number? -- 9-patch corner scale multiplier
|
|
---@field scalingAlgorithm string? -- 9-patch scaling algorithm
|
|
---@field disableHighlight boolean -- Disable pressed state highlight
|
|
local Renderer = {}
|
|
Renderer.__index = Renderer
|
|
|
|
--- Create a new Renderer instance
|
|
---@param config table -- Configuration options
|
|
---@return Renderer
|
|
function Renderer.new(config)
|
|
local self = setmetatable({}, Renderer)
|
|
|
|
-- Initialize rendering state
|
|
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
|
|
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
|
|
self.opacity = config.opacity or 1
|
|
|
|
-- Border configuration
|
|
self.border = config.border or {
|
|
top = false,
|
|
right = false,
|
|
bottom = false,
|
|
left = false,
|
|
}
|
|
|
|
-- Corner radius configuration
|
|
self.cornerRadius = config.cornerRadius or {
|
|
topLeft = 0,
|
|
topRight = 0,
|
|
bottomLeft = 0,
|
|
bottomRight = 0,
|
|
}
|
|
|
|
-- Theme configuration
|
|
self.theme = config.theme
|
|
self.themeComponent = config.themeComponent
|
|
self._themeState = config._themeState or "normal"
|
|
|
|
-- Image configuration
|
|
self.imagePath = config.imagePath
|
|
self.image = config.image
|
|
self._loadedImage = config._loadedImage
|
|
self.objectFit = config.objectFit or "fill"
|
|
self.objectPosition = config.objectPosition or "center center"
|
|
self.imageOpacity = config.imageOpacity or 1
|
|
|
|
-- Blur configuration
|
|
self.contentBlur = config.contentBlur
|
|
self.backdropBlur = config.backdropBlur
|
|
self._blurInstance = config._blurInstance
|
|
|
|
-- 9-patch configuration
|
|
self.scaleCorners = config.scaleCorners
|
|
self.scalingAlgorithm = config.scalingAlgorithm
|
|
|
|
-- Visual feedback configuration
|
|
self.disableHighlight = config.disableHighlight or false
|
|
|
|
-- Element reference (set via initialize)
|
|
self.element = nil
|
|
|
|
return self
|
|
end
|
|
|
|
--- Initialize renderer with parent element reference
|
|
---@param element Element
|
|
function Renderer:initialize(element)
|
|
self.element = element
|
|
end
|
|
|
|
--- Main draw method - orchestrates all rendering
|
|
---@param backdropCanvas love.Canvas? -- Canvas for backdrop blur
|
|
function Renderer:draw(backdropCanvas)
|
|
-- Early exit if element is invisible (optimization)
|
|
if self.opacity <= 0 then
|
|
return
|
|
end
|
|
|
|
-- Get element reference for convenience
|
|
local element = self.element
|
|
if not element then
|
|
return
|
|
end
|
|
|
|
-- Handle opacity during animation
|
|
local drawBackgroundColor = self.backgroundColor
|
|
if element.animation then
|
|
local anim = element.animation:interpolate()
|
|
if anim.opacity then
|
|
drawBackgroundColor = Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
|
|
end
|
|
end
|
|
|
|
-- Cache border box dimensions for this draw call (optimization)
|
|
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
|
local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
|
|
|
-- LAYER 0.5: Draw backdrop blur if configured (before background)
|
|
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
|
|
local blurInstance = element:getBlurInstance()
|
|
if blurInstance then
|
|
Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas)
|
|
end
|
|
end
|
|
|
|
-- LAYER 1: Draw backgroundColor first (behind everything)
|
|
self:drawBackground(element.x, element.y, borderBoxWidth, borderBoxHeight, drawBackgroundColor)
|
|
|
|
-- LAYER 1.5: Draw image on top of backgroundColor (if image exists)
|
|
if self._loadedImage then
|
|
self:drawImage(element.x, element.y, borderBoxWidth, borderBoxHeight)
|
|
end
|
|
|
|
-- LAYER 2: Draw theme on top of backgroundColor (if theme exists)
|
|
if self.themeComponent then
|
|
self:drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight)
|
|
end
|
|
|
|
-- LAYER 3: Draw borders on top of theme (always render if specified)
|
|
self:drawBorder(element.x, element.y, borderBoxWidth, borderBoxHeight)
|
|
end
|
|
|
|
--- Draw background with corner radius
|
|
---@param x number
|
|
---@param y number
|
|
---@param width number
|
|
---@param height number
|
|
---@param drawBackgroundColor Color? -- Optional override for background color
|
|
function Renderer:drawBackground(x, y, width, height, drawBackgroundColor)
|
|
drawBackgroundColor = drawBackgroundColor or self.backgroundColor
|
|
|
|
-- Apply opacity to background color
|
|
local backgroundWithOpacity = Color.new(
|
|
drawBackgroundColor.r,
|
|
drawBackgroundColor.g,
|
|
drawBackgroundColor.b,
|
|
drawBackgroundColor.a * self.opacity
|
|
)
|
|
|
|
love.graphics.setColor(backgroundWithOpacity:toRGBA())
|
|
RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
|
|
end
|
|
|
|
--- Draw image with object-fit modes
|
|
---@param x number
|
|
---@param y number
|
|
---@param borderBoxWidth number
|
|
---@param borderBoxHeight number
|
|
function Renderer:drawImage(x, y, borderBoxWidth, borderBoxHeight)
|
|
if not self._loadedImage or not self.element then
|
|
return
|
|
end
|
|
|
|
local element = self.element
|
|
|
|
-- Calculate image bounds (content area - respects padding)
|
|
local imageX = x + element.padding.left
|
|
local imageY = y + element.padding.top
|
|
local imageWidth = element.width
|
|
local imageHeight = element.height
|
|
|
|
-- Combine element opacity with imageOpacity
|
|
local finalOpacity = self.opacity * self.imageOpacity
|
|
|
|
-- Apply cornerRadius clipping if set
|
|
local hasCornerRadius = self.cornerRadius.topLeft > 0
|
|
or self.cornerRadius.topRight > 0
|
|
or self.cornerRadius.bottomLeft > 0
|
|
or self.cornerRadius.bottomRight > 0
|
|
|
|
if hasCornerRadius then
|
|
-- Use stencil to clip image to rounded corners
|
|
love.graphics.stencil(function()
|
|
RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
end, "replace", 1)
|
|
love.graphics.setStencilTest("greater", 0)
|
|
end
|
|
|
|
-- Draw the image
|
|
ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
|
|
|
|
-- Clear stencil if it was used
|
|
if hasCornerRadius then
|
|
love.graphics.setStencilTest()
|
|
end
|
|
end
|
|
|
|
--- Draw theme component using 9-patch rendering
|
|
---@param x number
|
|
---@param y number
|
|
---@param borderBoxWidth number
|
|
---@param borderBoxHeight number
|
|
function Renderer:drawTheme(x, y, borderBoxWidth, borderBoxHeight)
|
|
if not self.themeComponent or not self.element then
|
|
return
|
|
end
|
|
|
|
-- Get the theme to use
|
|
local themeToUse = nil
|
|
if self.theme then
|
|
-- Element specifies a specific theme - load it if needed
|
|
if Theme.get(self.theme) then
|
|
themeToUse = Theme.get(self.theme)
|
|
else
|
|
-- Try to load the theme
|
|
pcall(function()
|
|
Theme.load(self.theme)
|
|
end)
|
|
themeToUse = Theme.get(self.theme)
|
|
end
|
|
else
|
|
-- Use active theme
|
|
themeToUse = Theme.getActive()
|
|
end
|
|
|
|
if not themeToUse then
|
|
return
|
|
end
|
|
|
|
-- Get the component from the theme
|
|
local component = themeToUse.components[self.themeComponent]
|
|
if not component then
|
|
return
|
|
end
|
|
|
|
-- Check for state-specific override
|
|
local state = self._themeState
|
|
if state and component.states and component.states[state] then
|
|
component = component.states[state]
|
|
end
|
|
|
|
-- Use component-specific atlas if available, otherwise use theme atlas
|
|
local atlasToUse = component._loadedAtlas or themeToUse.atlas
|
|
|
|
if not atlasToUse or not component.regions then
|
|
return
|
|
end
|
|
|
|
-- Validate component has required structure
|
|
local hasAllRegions = component.regions.topLeft
|
|
and component.regions.topCenter
|
|
and component.regions.topRight
|
|
and component.regions.middleLeft
|
|
and component.regions.middleCenter
|
|
and component.regions.middleRight
|
|
and component.regions.bottomLeft
|
|
and component.regions.bottomCenter
|
|
and component.regions.bottomRight
|
|
|
|
if not hasAllRegions then
|
|
return
|
|
end
|
|
|
|
-- Pass element-level overrides for scaleCorners and scalingAlgorithm
|
|
NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm)
|
|
end
|
|
|
|
--- Draw borders on specified sides
|
|
---@param x number
|
|
---@param y number
|
|
---@param width number
|
|
---@param height number
|
|
function Renderer:drawBorder(x, y, width, height)
|
|
-- Apply opacity to border color
|
|
local borderColorWithOpacity = Color.new(
|
|
self.borderColor.r,
|
|
self.borderColor.g,
|
|
self.borderColor.b,
|
|
self.borderColor.a * self.opacity
|
|
)
|
|
|
|
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
|
|
|
-- Check if all borders are enabled
|
|
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
|
|
|
|
if allBorders then
|
|
-- Draw complete rounded rectangle border
|
|
RoundedRect.draw("line", x, y, width, height, self.cornerRadius)
|
|
else
|
|
-- Draw individual borders (without rounded corners for partial borders)
|
|
if self.border.top then
|
|
love.graphics.line(x, y, x + width, y)
|
|
end
|
|
if self.border.bottom then
|
|
love.graphics.line(x, y + height, x + width, y + height)
|
|
end
|
|
if self.border.left then
|
|
love.graphics.line(x, y, x, y + height)
|
|
end
|
|
if self.border.right then
|
|
love.graphics.line(x + width, y, x + width, y + height)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Draw pressed state highlight overlay
|
|
---@param x number
|
|
---@param y number
|
|
---@param width number
|
|
---@param height number
|
|
function Renderer:drawPressedHighlight(x, y, width, height)
|
|
if self.disableHighlight or not self.element then
|
|
return
|
|
end
|
|
|
|
local element = self.element
|
|
|
|
-- Check if element has onEvent handler
|
|
if not element.onEvent then
|
|
return
|
|
end
|
|
|
|
-- Check if any button is pressed
|
|
local anyPressed = false
|
|
for _, pressed in pairs(element._pressed) do
|
|
if pressed then
|
|
anyPressed = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if anyPressed then
|
|
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity
|
|
RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
|
|
end
|
|
end
|
|
|
|
--- Set background color
|
|
---@param color Color
|
|
function Renderer:setBackgroundColor(color)
|
|
self.backgroundColor = color
|
|
end
|
|
|
|
--- Set border color
|
|
---@param color Color
|
|
function Renderer:setBorderColor(color)
|
|
self.borderColor = color
|
|
end
|
|
|
|
--- Set opacity
|
|
---@param opacity number
|
|
function Renderer:setOpacity(opacity)
|
|
self.opacity = opacity
|
|
end
|
|
|
|
--- Set theme state
|
|
---@param state string
|
|
function Renderer:setThemeState(state)
|
|
self._themeState = state
|
|
end
|
|
|
|
--- Set loaded image
|
|
---@param image love.Image?
|
|
function Renderer:setLoadedImage(image)
|
|
self._loadedImage = image
|
|
end
|
|
|
|
--- Get blur instance (delegates to element)
|
|
---@return table?
|
|
function Renderer:getBlurInstance()
|
|
if not self.element then
|
|
return nil
|
|
end
|
|
return self.element:getBlurInstance()
|
|
end
|
|
|
|
--- Update renderer state from element
|
|
--- Call this when element properties change
|
|
function Renderer:syncFromElement()
|
|
if not self.element then
|
|
return
|
|
end
|
|
|
|
local element = self.element
|
|
|
|
-- Sync rendering properties
|
|
self.backgroundColor = element.backgroundColor
|
|
self.borderColor = element.borderColor
|
|
self.opacity = element.opacity
|
|
self.border = element.border
|
|
self.cornerRadius = element.cornerRadius
|
|
self.theme = element.theme
|
|
self.themeComponent = element.themeComponent
|
|
self._themeState = element._themeState
|
|
self.imagePath = element.imagePath
|
|
self.image = element.image
|
|
self._loadedImage = element._loadedImage
|
|
self.objectFit = element.objectFit
|
|
self.objectPosition = element.objectPosition
|
|
self.imageOpacity = element.imageOpacity
|
|
self.contentBlur = element.contentBlur
|
|
self.backdropBlur = element.backdropBlur
|
|
self._blurInstance = element._blurInstance
|
|
self.scaleCorners = element.scaleCorners
|
|
self.scalingAlgorithm = element.scalingAlgorithm
|
|
self.disableHighlight = element.disableHighlight
|
|
end
|
|
|
|
--- Update element state from renderer
|
|
--- Call this when renderer properties change
|
|
function Renderer:syncToElement()
|
|
if not self.element then
|
|
return
|
|
end
|
|
|
|
local element = self.element
|
|
|
|
-- Sync rendering properties back to element
|
|
element.backgroundColor = self.backgroundColor
|
|
element.borderColor = self.borderColor
|
|
element.opacity = self.opacity
|
|
element.border = self.border
|
|
element.cornerRadius = self.cornerRadius
|
|
element.theme = self.theme
|
|
element.themeComponent = self.themeComponent
|
|
element._themeState = self._themeState
|
|
element.imagePath = self.imagePath
|
|
element.image = self.image
|
|
element._loadedImage = self._loadedImage
|
|
element.objectFit = self.objectFit
|
|
element.objectPosition = self.objectPosition
|
|
element.imageOpacity = self.imageOpacity
|
|
element.contentBlur = self.contentBlur
|
|
element.backdropBlur = self.backdropBlur
|
|
element._blurInstance = self._blurInstance
|
|
element.scaleCorners = self.scaleCorners
|
|
element.scalingAlgorithm = self.scalingAlgorithm
|
|
element.disableHighlight = self.disableHighlight
|
|
end
|
|
|
|
return Renderer
|