This commit is contained in:
Michael Freno
2025-11-12 17:53:22 -05:00
parent 7faa8b4ec6
commit 1293ca81a4
7 changed files with 3868 additions and 4611 deletions

View File

@@ -1,436 +0,0 @@
--[[
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