437 lines
13 KiB
Lua
437 lines
13 KiB
Lua
--[[
|
|
ThemeManager - Theme and State Management for FlexLove Elements
|
|
Extracts all theme-related functionality from Element.lua into a dedicated module.
|
|
Handles theme state management, component loading, 9-patch rendering, and property resolution.
|
|
]]
|
|
|
|
-- Setup module path for relative requires
|
|
local modulePath = (...):match("(.-)[^%.]+$")
|
|
local function req(name)
|
|
return require(modulePath .. name)
|
|
end
|
|
|
|
-- Module dependencies
|
|
local Theme = req("Theme")
|
|
local NinePatch = req("NinePatch")
|
|
local StateManager = req("StateManager")
|
|
|
|
--- Standardized error message formatter
|
|
---@param module string -- Module name
|
|
---@param message string -- Error message
|
|
---@return string -- Formatted error message
|
|
local function formatError(module, message)
|
|
return string.format("[FlexLove.%s] %s", module, message)
|
|
end
|
|
|
|
---@class ThemeManager
|
|
---@field theme string? -- Theme name to use
|
|
---@field themeComponent string? -- Component name from theme
|
|
---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled)
|
|
---@field disabled boolean -- Whether element is disabled
|
|
---@field active boolean -- Whether element is active/focused
|
|
---@field disableHighlight boolean -- Whether to disable pressed state highlight
|
|
---@field scaleCorners number? -- Scale multiplier for 9-patch corners
|
|
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch
|
|
---@field contentAutoSizingMultiplier table? -- Multiplier for auto-sized content
|
|
---@field _element Element? -- Reference to parent element (set via initialize)
|
|
---@field _stateId string? -- State manager ID for immediate mode
|
|
local ThemeManager = {}
|
|
ThemeManager.__index = ThemeManager
|
|
|
|
--- Create a new ThemeManager instance
|
|
---@param config table -- Configuration options
|
|
---@return ThemeManager
|
|
function ThemeManager.new(config)
|
|
local self = setmetatable({}, ThemeManager)
|
|
|
|
-- Theme configuration
|
|
self.theme = config.theme
|
|
self.themeComponent = config.themeComponent
|
|
|
|
-- State properties
|
|
self._themeState = "normal"
|
|
self.disabled = config.disabled or false
|
|
self.active = config.active or false
|
|
self.disableHighlight = config.disableHighlight
|
|
|
|
-- 9-patch rendering properties
|
|
self.scaleCorners = config.scaleCorners
|
|
self.scalingAlgorithm = config.scalingAlgorithm
|
|
|
|
-- Content sizing properties
|
|
self.contentAutoSizingMultiplier = config.contentAutoSizingMultiplier
|
|
|
|
-- Element reference (set via initialize)
|
|
self._element = nil
|
|
self._stateId = config.stateId
|
|
|
|
return self
|
|
end
|
|
|
|
--- Initialize ThemeManager with parent element reference
|
|
--- This links the ThemeManager to its parent element for accessing dimensions and state
|
|
---@param element Element -- Parent element
|
|
function ThemeManager:initialize(element)
|
|
self._element = element
|
|
self._stateId = element._stateId or element.id
|
|
end
|
|
|
|
--- Update theme state based on interaction
|
|
--- State priority: disabled > pressed > active > hover > normal
|
|
---@param isHovered boolean -- Whether element is hovered
|
|
---@param isPressed boolean -- Whether element is pressed (any button)
|
|
---@param isFocused boolean -- Whether element is focused
|
|
---@param isDisabled boolean -- Whether element is disabled
|
|
function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled)
|
|
if not self.themeComponent then
|
|
return
|
|
end
|
|
|
|
local newThemeState = "normal"
|
|
|
|
-- State priority: disabled > active > pressed > hover > normal
|
|
if isDisabled or self.disabled then
|
|
newThemeState = "disabled"
|
|
elseif self.active or isFocused then
|
|
newThemeState = "active"
|
|
elseif isPressed then
|
|
newThemeState = "pressed"
|
|
elseif isHovered then
|
|
newThemeState = "hover"
|
|
end
|
|
|
|
-- Update local state
|
|
self._themeState = newThemeState
|
|
|
|
-- Update StateManager if in immediate mode
|
|
if self._stateId then
|
|
local GuiState = req("GuiState")
|
|
if GuiState._immediateMode then
|
|
StateManager.updateState(self._stateId, {
|
|
hover = (newThemeState == "hover"),
|
|
pressed = (newThemeState == "pressed"),
|
|
focused = (newThemeState == "active" or isFocused),
|
|
disabled = isDisabled or self.disabled,
|
|
active = self.active,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Get current theme state
|
|
---@return string -- Current state (normal, hover, pressed, active, disabled)
|
|
function ThemeManager:getState()
|
|
return self._themeState
|
|
end
|
|
|
|
--- Get theme component for current state
|
|
--- Returns the component data with state-specific overrides applied
|
|
---@return table|nil -- Component data or nil if not found
|
|
function ThemeManager:getThemeComponent()
|
|
if not self.themeComponent then
|
|
return nil
|
|
end
|
|
|
|
-- Get the theme to use
|
|
local themeToUse = self:_getTheme()
|
|
if not themeToUse then
|
|
return nil
|
|
end
|
|
|
|
-- Get the component from the theme
|
|
local component = themeToUse.components[self.themeComponent]
|
|
if not component then
|
|
return nil
|
|
end
|
|
|
|
-- Check for state-specific override
|
|
local state = self._themeState
|
|
if state and state ~= "normal" and component.states and component.states[state] then
|
|
component = component.states[state]
|
|
end
|
|
|
|
return component
|
|
end
|
|
|
|
--- Check if theme component exists
|
|
---@return boolean
|
|
function ThemeManager:hasThemeComponent()
|
|
return self.themeComponent ~= nil and self:getThemeComponent() ~= nil
|
|
end
|
|
|
|
--- Get the theme to use (element theme or active theme)
|
|
---@return table|nil -- Theme data or nil if not found
|
|
function ThemeManager:_getTheme()
|
|
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
|
|
|
|
return themeToUse
|
|
end
|
|
|
|
--- Get atlas image for current component
|
|
---@return love.Image|nil -- Atlas image or nil
|
|
function ThemeManager:_getAtlas()
|
|
local component = self:getThemeComponent()
|
|
if not component then
|
|
return nil
|
|
end
|
|
|
|
local themeToUse = self:_getTheme()
|
|
if not themeToUse then
|
|
return nil
|
|
end
|
|
|
|
-- Use component-specific atlas if available, otherwise use theme atlas
|
|
return component._loadedAtlas or themeToUse.atlas
|
|
end
|
|
|
|
--- Render theme component (9-patch or other)
|
|
---@param x number -- X position
|
|
---@param y number -- Y position
|
|
---@param width number -- Width (border-box)
|
|
---@param height number -- Height (border-box)
|
|
---@param opacity number? -- Opacity (0-1)
|
|
function ThemeManager:render(x, y, width, height, opacity)
|
|
if not self.themeComponent then
|
|
return
|
|
end
|
|
|
|
opacity = opacity or 1
|
|
|
|
-- Get the theme to use
|
|
local themeToUse = self:_getTheme()
|
|
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 atlasToUse and component.regions then
|
|
-- Validate component has required structure for 9-patch
|
|
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 hasAllRegions then
|
|
-- Render 9-patch with element-level overrides
|
|
NinePatch.draw(
|
|
component,
|
|
atlasToUse,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
opacity,
|
|
self.scaleCorners,
|
|
self.scalingAlgorithm
|
|
)
|
|
else
|
|
-- Silently skip drawing if component structure is invalid
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Get styled property value from theme for current state
|
|
--- This allows theme components to provide default values for properties
|
|
---@param property string -- Property name (e.g., "backgroundColor", "textColor")
|
|
---@return any|nil -- Property value or nil if not found
|
|
function ThemeManager:getStyle(property)
|
|
local component = self:getThemeComponent()
|
|
if not component then
|
|
return nil
|
|
end
|
|
|
|
-- Check if component has style properties
|
|
if component.style and component.style[property] then
|
|
return component.style[property]
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
--- Set theme and component
|
|
---@param themeName string? -- Theme name
|
|
---@param componentName string? -- Component name
|
|
function ThemeManager:setTheme(themeName, componentName)
|
|
self.theme = themeName
|
|
self.themeComponent = componentName
|
|
end
|
|
|
|
--- Get scale corners multiplier
|
|
---@return number|nil
|
|
function ThemeManager:getScaleCorners()
|
|
-- Element-level override takes priority
|
|
if self.scaleCorners ~= nil then
|
|
return self.scaleCorners
|
|
end
|
|
|
|
-- Fall back to component setting
|
|
local component = self:getThemeComponent()
|
|
if component and component.scaleCorners then
|
|
return component.scaleCorners
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
--- Get scaling algorithm
|
|
---@return "nearest"|"bilinear"
|
|
function ThemeManager:getScalingAlgorithm()
|
|
-- Element-level override takes priority
|
|
if self.scalingAlgorithm ~= nil then
|
|
return self.scalingAlgorithm
|
|
end
|
|
|
|
-- Fall back to component setting
|
|
local component = self:getThemeComponent()
|
|
if component and component.scalingAlgorithm then
|
|
return component.scalingAlgorithm
|
|
end
|
|
|
|
-- Default to bilinear
|
|
return "bilinear"
|
|
end
|
|
|
|
--- Get the current state's scaled content padding
|
|
--- Returns the contentPadding for the current theme state, scaled to the element's size
|
|
---@param borderBoxWidth number -- Border-box width
|
|
---@param borderBoxHeight number -- Border-box height
|
|
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
|
|
function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
|
|
if not self.themeComponent then
|
|
return nil
|
|
end
|
|
|
|
local themeToUse = self:_getTheme()
|
|
if not themeToUse or not themeToUse.components[self.themeComponent] then
|
|
return nil
|
|
end
|
|
|
|
local component = themeToUse.components[self.themeComponent]
|
|
|
|
-- Check for state-specific override
|
|
local state = self._themeState or "normal"
|
|
if state and state ~= "normal" and component.states and component.states[state] then
|
|
component = component.states[state]
|
|
end
|
|
|
|
if not component._ninePatchData or not component._ninePatchData.contentPadding then
|
|
return nil
|
|
end
|
|
|
|
local contentPadding = component._ninePatchData.contentPadding
|
|
|
|
-- Scale contentPadding to match the actual rendered size
|
|
local atlasImage = component._loadedAtlas or themeToUse.atlas
|
|
if atlasImage and type(atlasImage) ~= "string" then
|
|
local originalWidth, originalHeight = atlasImage:getDimensions()
|
|
local scaleX = borderBoxWidth / originalWidth
|
|
local scaleY = borderBoxHeight / originalHeight
|
|
|
|
return {
|
|
left = contentPadding.left * scaleX,
|
|
top = contentPadding.top * scaleY,
|
|
right = contentPadding.right * scaleX,
|
|
bottom = contentPadding.bottom * scaleY,
|
|
}
|
|
else
|
|
-- Return unscaled values as fallback
|
|
return {
|
|
left = contentPadding.left,
|
|
top = contentPadding.top,
|
|
right = contentPadding.right,
|
|
bottom = contentPadding.bottom,
|
|
}
|
|
end
|
|
end
|
|
|
|
--- Get content auto-sizing multiplier from theme
|
|
--- Priority: element config > theme component > theme default
|
|
---@return table -- {width, height} multipliers
|
|
function ThemeManager:getContentAutoSizingMultiplier()
|
|
-- If explicitly set in config, use that
|
|
if self.contentAutoSizingMultiplier then
|
|
return self.contentAutoSizingMultiplier
|
|
end
|
|
|
|
-- Try to source from theme
|
|
local themeToUse = self:_getTheme()
|
|
if themeToUse then
|
|
-- First check if themeComponent has a multiplier
|
|
if self.themeComponent then
|
|
local component = themeToUse.components[self.themeComponent]
|
|
if component and component.contentAutoSizingMultiplier then
|
|
return component.contentAutoSizingMultiplier
|
|
elseif themeToUse.contentAutoSizingMultiplier then
|
|
-- Fall back to theme default
|
|
return themeToUse.contentAutoSizingMultiplier
|
|
end
|
|
elseif themeToUse.contentAutoSizingMultiplier then
|
|
return themeToUse.contentAutoSizingMultiplier
|
|
end
|
|
end
|
|
|
|
-- Default multiplier
|
|
return { 1, 1 }
|
|
end
|
|
|
|
--- Update disabled state
|
|
---@param disabled boolean
|
|
function ThemeManager:setDisabled(disabled)
|
|
self.disabled = disabled
|
|
end
|
|
|
|
--- Update active state
|
|
---@param active boolean
|
|
function ThemeManager:setActive(active)
|
|
self.active = active
|
|
end
|
|
|
|
--- Get disabled state
|
|
---@return boolean
|
|
function ThemeManager:isDisabled()
|
|
return self.disabled
|
|
end
|
|
|
|
--- Get active state
|
|
---@return boolean
|
|
function ThemeManager:isActive()
|
|
return self.active
|
|
end
|
|
|
|
return ThemeManager
|