theme manager module

This commit is contained in:
Michael Freno
2025-11-12 20:56:06 -05:00
parent 91e4af9b96
commit 3df8718a62
2 changed files with 342 additions and 438 deletions

View File

@@ -28,6 +28,7 @@ local LayoutEngine = req("LayoutEngine")
local Renderer = req("Renderer") local Renderer = req("Renderer")
local EventHandler = req("EventHandler") local EventHandler = req("EventHandler")
local ScrollManager = req("ScrollManager") local ScrollManager = req("ScrollManager")
local ThemeManager = req("ThemeManager")
-- Extract utilities -- Extract utilities
local enums = utils.enums local enums = utils.enums
@@ -206,22 +207,27 @@ function Element.new(props)
}) })
self._eventHandler:initialize(self) self._eventHandler:initialize(self)
-- Initialize theme state (will be managed by StateManager in immediate mode)
self._themeState = "normal"
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id self._stateId = self.id
-- Handle theme property: -- Initialize ThemeManager for theme management
-- - theme: which theme to use (defaults to Gui.defaultTheme if not specified) self._themeManager = ThemeManager.new({
-- - themeComponent: which component from the theme (e.g., "panel", "button", "input") theme = props.theme or Gui.defaultTheme,
-- If themeComponent is nil, no theme is applied (manual styling) themeComponent = props.themeComponent or nil,
self.theme = props.theme or Gui.defaultTheme disabled = props.disabled or false,
self.themeComponent = props.themeComponent or nil active = props.active or false,
disableHighlight = props.disableHighlight,
scaleCorners = props.scaleCorners,
scalingAlgorithm = props.scalingAlgorithm,
})
self._themeManager:initialize(self)
-- Initialize state properties -- Expose theme properties for backward compatibility
self.disabled = props.disabled or false self.theme = self._themeManager.theme
self.active = props.active or false self.themeComponent = self._themeManager.themeComponent
self.disabled = self._themeManager.disabled
self.active = self._themeManager.active
self._themeState = self._themeManager:getState()
-- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback) -- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback)
-- Can be explicitly overridden by setting props.disableHighlight -- Can be explicitly overridden by setting props.disableHighlight
@@ -237,34 +243,14 @@ function Element.new(props)
-- Explicitly set on element -- Explicitly set on element
self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier
else else
-- Try to source from theme -- Try to source from theme via ThemeManager
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local multiplier = self._themeManager:getContentAutoSizingMultiplier()
if themeToUse then self.contentAutoSizingMultiplier = multiplier or { 1, 1 }
-- First check if themeComponent has a multiplier
if self.themeComponent then
local component = themeToUse.components[self.themeComponent]
if component and component.contentAutoSizingMultiplier then
self.contentAutoSizingMultiplier = component.contentAutoSizingMultiplier
elseif themeToUse.contentAutoSizingMultiplier then
-- Fall back to theme default
self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier
else
self.contentAutoSizingMultiplier = { 1, 1 }
end
elseif themeToUse.contentAutoSizingMultiplier then
self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier
else
self.contentAutoSizingMultiplier = { 1, 1 }
end
else
self.contentAutoSizingMultiplier = { 1, 1 }
end
end end
-- Initialize 9-patch corner scaling properties -- Expose 9-patch corner scaling properties for backward compatibility
-- These override theme component settings when specified self.scaleCorners = self._themeManager.scaleCorners
self.scaleCorners = props.scaleCorners self.scalingAlgorithm = self._themeManager.scalingAlgorithm
self.scalingAlgorithm = props.scalingAlgorithm
-- Initialize blur properties -- Initialize blur properties
self.contentBlur = props.contentBlur self.contentBlur = props.contentBlur
@@ -502,13 +488,9 @@ function Element.new(props)
-- Inherit from parent if parent has fontFamily set -- Inherit from parent if parent has fontFamily set
self.fontFamily = self.parent.fontFamily self.fontFamily = self.parent.fontFamily
elseif props.themeComponent then elseif props.themeComponent then
-- If using themeComponent, try to get default from theme -- If using themeComponent, try to get default from theme via ThemeManager
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local defaultFont = self._themeManager:getDefaultFontFamily()
if themeToUse and themeToUse.fonts and themeToUse.fonts["default"] then self.fontFamily = defaultFont and "default" or nil
self.fontFamily = "default"
else
self.fontFamily = nil
end
else else
self.fontFamily = nil self.fontFamily = nil
end end
@@ -671,26 +653,23 @@ function Element.new(props)
-- Check if we should use 9-patch content padding for auto-sizing -- Check if we should use 9-patch content padding for auto-sizing
local use9PatchPadding = false local use9PatchPadding = false
local ninePatchContentPadding = nil local ninePatchContentPadding = nil
if self.themeComponent then if self._themeManager:hasThemeComponent() then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local component = self._themeManager:getComponent()
if themeToUse and themeToUse.components[self.themeComponent] then if component and component._ninePatchData and component._ninePatchData.contentPadding then
local component = themeToUse.components[self.themeComponent] -- Only use 9-patch padding if no explicit padding was provided
if component._ninePatchData and component._ninePatchData.contentPadding then if
-- Only use 9-patch padding if no explicit padding was provided not props.padding
if or (
not props.padding not props.padding.top
or ( and not props.padding.right
not props.padding.top and not props.padding.bottom
and not props.padding.right and not props.padding.left
and not props.padding.bottom and not props.padding.horizontal
and not props.padding.left and not props.padding.vertical
and not props.padding.horizontal )
and not props.padding.vertical then
) use9PatchPadding = true
then ninePatchContentPadding = component._ninePatchData.contentPadding
use9PatchPadding = true
ninePatchContentPadding = component._ninePatchData.contentPadding
end
end end
end end
end end
@@ -699,39 +678,12 @@ function Element.new(props)
-- For auto-sized elements, this is content width; for explicit sizing, this is border-box width -- For auto-sized elements, this is content width; for explicit sizing, this is border-box width
local tempPadding local tempPadding
if use9PatchPadding then if use9PatchPadding then
-- Scale 9-patch content padding to match the actual rendered size -- Get scaled 9-patch content padding from ThemeManager
-- The contentPadding values are in the original image's pixel coordinates, local scaledPadding = self._themeManager:getScaledContentPadding(tempWidth, tempHeight)
-- but we need to scale them proportionally to the element's actual size if scaledPadding then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() tempPadding = scaledPadding
if themeToUse and themeToUse.components[self.themeComponent] then
local component = themeToUse.components[self.themeComponent]
local atlasImage = component._loadedAtlas or themeToUse.atlas
if atlasImage and type(atlasImage) ~= "string" then
local originalWidth, originalHeight = atlasImage:getDimensions()
-- Calculate the scale factor based on the element's border-box size vs original image size
-- For explicit sizing, tempWidth/tempHeight represent the border-box dimensions
local scaleX = tempWidth / originalWidth
local scaleY = tempHeight / originalHeight
tempPadding = {
left = ninePatchContentPadding.left * scaleX,
top = ninePatchContentPadding.top * scaleY,
right = ninePatchContentPadding.right * scaleX,
bottom = ninePatchContentPadding.bottom * scaleY,
}
else
-- Fallback if atlas image not available
tempPadding = {
left = ninePatchContentPadding.left,
top = ninePatchContentPadding.top,
right = ninePatchContentPadding.right,
bottom = ninePatchContentPadding.bottom,
}
end
else else
-- Fallback if theme not found -- Fallback if scaling fails
tempPadding = { tempPadding = {
left = ninePatchContentPadding.left, left = ninePatchContentPadding.left,
top = ninePatchContentPadding.top, top = ninePatchContentPadding.top,
@@ -924,8 +876,8 @@ function Element.new(props)
if props.textColor then if props.textColor then
self.textColor = props.textColor self.textColor = props.textColor
else else
-- Try to get text color from theme -- Try to get text color from theme via ThemeManager
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.colors and themeToUse.colors.text then if themeToUse and themeToUse.colors and themeToUse.colors.text then
self.textColor = themeToUse.colors.text self.textColor = themeToUse.colors.text
else else
@@ -1055,8 +1007,8 @@ function Element.new(props)
elseif self.parent.textColor then elseif self.parent.textColor then
self.textColor = self.parent.textColor self.textColor = self.parent.textColor
else else
-- Try to get text color from theme -- Try to get text color from theme via ThemeManager
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.colors and themeToUse.colors.text then if themeToUse and themeToUse.colors and themeToUse.colors.text then
self.textColor = themeToUse.colors.text self.textColor = themeToUse.colors.text
else else
@@ -1492,53 +1444,9 @@ end
--- Returns the contentPadding for the current theme state, scaled to the element's size --- Returns the contentPadding for the current theme state, scaled to the element's size
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding ---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
function Element:getScaledContentPadding() function Element:getScaledContentPadding()
if not self.themeComponent then local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
return nil local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
end return self._themeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive()
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 borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
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 end
--- Get or create blur instance for this element --- Get or create blur instance for this element
@@ -2048,25 +1956,16 @@ function Element:update(dt)
-- Update theme state based on interaction -- Update theme state based on interaction
if self.themeComponent then if self.themeComponent then
local newThemeState = "normal" -- Check if any button is pressed via EventHandler
local anyPressed = self._eventHandler:isAnyButtonPressed()
-- Disabled state takes priority -- Update theme state via ThemeManager
if self.disabled then local newThemeState = self._themeManager:updateState(
newThemeState = "disabled" isHovering and isActiveElement,
-- Active state (for inputs when focused/typing) anyPressed,
elseif self.active then self._focused,
newThemeState = "active" self.disabled
-- Only show hover/pressed states if this element is active (not blocked) )
elseif isHovering and isActiveElement then
-- Check if any button is pressed via EventHandler
local anyPressed = self._eventHandler:isAnyButtonPressed()
if anyPressed then
newThemeState = "pressed"
else
newThemeState = "hover"
end
end
-- Update state (in StateManager if in immediate mode, otherwise locally) -- Update state (in StateManager if in immediate mode, otherwise locally)
if self._stateId and Gui._immediateMode then if self._stateId and Gui._immediateMode then
@@ -2107,255 +2006,10 @@ end
---@param newViewportWidth number ---@param newViewportWidth number
---@param newViewportHeight number ---@param newViewportHeight number
function Element:recalculateUnits(newViewportWidth, newViewportHeight) function Element:recalculateUnits(newViewportWidth, newViewportHeight)
-- Get updated scale factors -- Delegate to LayoutEngine
local scaleX, scaleY = Gui.getScaleFactors() if self._layoutEngine then
self._layoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- Recalculate border-box width if using viewport or percentage units (skip auto-sized)
-- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved
if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then
local parentWidth = self.parent and self.parent.width or newViewportWidth
self._borderBoxWidth = Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth)
elseif self.units.width.unit == "px" and self.units.width.value and Gui.baseScale then
-- Reapply base scaling to pixel widths (border-box)
self._borderBoxWidth = self.units.width.value * scaleX
end end
-- Recalculate border-box height if using viewport or percentage units (skip auto-sized)
-- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved
if self.units.height.unit ~= "px" and self.units.height.unit ~= "auto" then
local parentHeight = self.parent and self.parent.height or newViewportHeight
self._borderBoxHeight = Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight)
elseif self.units.height.unit == "px" and self.units.height.value and Gui.baseScale then
-- Reapply base scaling to pixel heights (border-box)
self._borderBoxHeight = self.units.height.value * scaleY
end
-- Recalculate position if using viewport or percentage units
if self.units.x.unit ~= "px" then
local parentWidth = self.parent and self.parent.width or newViewportWidth
local baseX = self.parent and self.parent.x or 0
local offsetX = Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth)
self.x = baseX + offsetX
else
-- For pixel units, update position relative to parent's new position (with base scaling)
if self.parent then
local baseX = self.parent.x
local scaledOffset = Gui.baseScale and (self.units.x.value * scaleX) or self.units.x.value
self.x = baseX + scaledOffset
elseif Gui.baseScale then
-- Top-level element with pixel position - apply base scaling
self.x = self.units.x.value * scaleX
end
end
if self.units.y.unit ~= "px" then
local parentHeight = self.parent and self.parent.height or newViewportHeight
local baseY = self.parent and self.parent.y or 0
local offsetY = Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight)
self.y = baseY + offsetY
else
-- For pixel units, update position relative to parent's new position (with base scaling)
if self.parent then
local baseY = self.parent.y
local scaledOffset = Gui.baseScale and (self.units.y.value * scaleY) or self.units.y.value
self.y = baseY + scaledOffset
elseif Gui.baseScale then
-- Top-level element with pixel position - apply base scaling
self.y = self.units.y.value * scaleY
end
end
-- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units
if self.autoScaleText and self.units.textSize.value then
local unit = self.units.textSize.unit
local value = self.units.textSize.value
if unit == "px" and Gui.baseScale then
-- With base scaling: scale pixel values relative to base resolution
self.textSize = value * scaleY
elseif unit == "px" then
-- Without base scaling but auto-scaling enabled: text doesn't scale
self.textSize = value
elseif unit == "%" or unit == "vh" then
-- Percentage and vh are relative to viewport height
self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight)
elseif unit == "vw" then
-- vw is relative to viewport width
self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth)
elseif unit == "ew" then
-- Element width relative
self.textSize = (value / 100) * self.width
elseif unit == "eh" then
-- Element height relative
self.textSize = (value / 100) * self.height
else
self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil)
end
-- Apply min/max constraints (with base scaling)
local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end
if maxSize and self.textSize > maxSize then
self.textSize = maxSize
end
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
elseif self.units.textSize.unit == "px" and self.units.textSize.value and Gui.baseScale then
-- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes
self.textSize = self.units.textSize.value * scaleY
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
end
-- Final protection: ensure textSize is always at least 1px (catches all edge cases)
if self.text and self.textSize and self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
-- Recalculate gap if using viewport or percentage units
if self.units.gap.unit ~= "px" then
local containerSize = (self.flexDirection == FlexDirection.HORIZONTAL) and (self.parent and self.parent.width or newViewportWidth)
or (self.parent and self.parent.height or newViewportHeight)
self.gap = Units.resolve(self.units.gap.value, self.units.gap.unit, newViewportWidth, newViewportHeight, containerSize)
end
-- Recalculate spacing (padding/margin) if using viewport or percentage units
-- For percentage-based padding:
-- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements)
-- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements)
local parentBorderBoxWidth = self.parent and self.parent._borderBoxWidth or self._borderBoxWidth or newViewportWidth
local parentBorderBoxHeight = self.parent and self.parent._borderBoxHeight or self._borderBoxHeight or newViewportHeight
-- Handle shorthand properties first (horizontal/vertical)
local resolvedHorizontalPadding = nil
local resolvedVerticalPadding = nil
if self.units.padding.horizontal and self.units.padding.horizontal.unit ~= "px" then
resolvedHorizontalPadding =
Units.resolve(self.units.padding.horizontal.value, self.units.padding.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth)
elseif self.units.padding.horizontal and self.units.padding.horizontal.value then
resolvedHorizontalPadding = self.units.padding.horizontal.value
end
if self.units.padding.vertical and self.units.padding.vertical.unit ~= "px" then
resolvedVerticalPadding =
Units.resolve(self.units.padding.vertical.value, self.units.padding.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight)
elseif self.units.padding.vertical and self.units.padding.vertical.value then
resolvedVerticalPadding = self.units.padding.vertical.value
end
-- Resolve individual padding sides (with fallback to shorthand)
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
-- Check if this side was explicitly set or if we should use shorthand
local useShorthand = false
if not self.units.padding[side].explicit then
-- Not explicitly set, check if we have shorthand
if side == "left" or side == "right" then
useShorthand = resolvedHorizontalPadding ~= nil
elseif side == "top" or side == "bottom" then
useShorthand = resolvedVerticalPadding ~= nil
end
end
if useShorthand then
-- Use shorthand value
if side == "left" or side == "right" then
self.padding[side] = resolvedHorizontalPadding
else
self.padding[side] = resolvedVerticalPadding
end
elseif self.units.padding[side].unit ~= "px" then
-- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.padding[side] = Units.resolve(self.units.padding[side].value, self.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize)
end
-- If unit is "px" and not using shorthand, value stays the same
end
-- Handle margin shorthand properties
local resolvedHorizontalMargin = nil
local resolvedVerticalMargin = nil
if self.units.margin.horizontal and self.units.margin.horizontal.unit ~= "px" then
resolvedHorizontalMargin =
Units.resolve(self.units.margin.horizontal.value, self.units.margin.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth)
elseif self.units.margin.horizontal and self.units.margin.horizontal.value then
resolvedHorizontalMargin = self.units.margin.horizontal.value
end
if self.units.margin.vertical and self.units.margin.vertical.unit ~= "px" then
resolvedVerticalMargin =
Units.resolve(self.units.margin.vertical.value, self.units.margin.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight)
elseif self.units.margin.vertical and self.units.margin.vertical.value then
resolvedVerticalMargin = self.units.margin.vertical.value
end
-- Resolve individual margin sides (with fallback to shorthand)
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
-- Check if this side was explicitly set or if we should use shorthand
local useShorthand = false
if not self.units.margin[side].explicit then
-- Not explicitly set, check if we have shorthand
if side == "left" or side == "right" then
useShorthand = resolvedHorizontalMargin ~= nil
elseif side == "top" or side == "bottom" then
useShorthand = resolvedVerticalMargin ~= nil
end
end
if useShorthand then
-- Use shorthand value
if side == "left" or side == "right" then
self.margin[side] = resolvedHorizontalMargin
else
self.margin[side] = resolvedVerticalMargin
end
elseif self.units.margin[side].unit ~= "px" then
-- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.margin[side] = Units.resolve(self.units.margin[side].value, self.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize)
end
-- If unit is "px" and not using shorthand, value stays the same
end
-- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions
-- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier
-- Now we calculate content width/height by subtracting padding
-- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed)
if self.units.width.unit ~= "auto" and self.units.width.unit ~= "px" then
-- _borderBoxWidth was recalculated for viewport/percentage units
-- Calculate content width by subtracting padding
self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right)
elseif self.units.width.unit == "auto" then
-- For auto-sized elements, width is content width (calculated in resize method)
-- Update border-box to include padding
self._borderBoxWidth = self.width + self.padding.left + self.padding.right
end
-- For pixel units, width stays as-is (may have been manually modified)
if self.units.height.unit ~= "auto" and self.units.height.unit ~= "px" then
-- _borderBoxHeight was recalculated for viewport/percentage units
-- Calculate content height by subtracting padding
self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom)
elseif self.units.height.unit == "auto" then
-- For auto-sized elements, height is content height (calculated in resize method)
-- Update border-box to include padding
self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom
end
-- For pixel units, height stays as-is (may have been manually modified)
-- Detect overflow after layout calculations
self:_detectOverflow()
end end
--- Resize element and its children based on game window size change --- Resize element and its children based on game window size change
@@ -2449,17 +2103,14 @@ function Element:calculateTextWidth()
-- Resolve font path from font family (same logic as in draw) -- Resolve font path from font family (same logic as in draw)
local fontPath = nil local fontPath = nil
if self.fontFamily then if self.fontFamily then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then
fontPath = themeToUse.fonts[self.fontFamily] fontPath = themeToUse.fonts[self.fontFamily]
else else
fontPath = self.fontFamily fontPath = self.fontFamily
end end
elseif self.themeComponent then elseif self.themeComponent then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() fontPath = self._themeManager:getDefaultFontFamily()
if themeToUse and themeToUse.fonts and themeToUse.fonts.default then
fontPath = themeToUse.fonts.default
end
end end
local tempFont = FONT_CACHE.get(self.textSize, fontPath) local tempFont = FONT_CACHE.get(self.textSize, fontPath)
@@ -2492,17 +2143,14 @@ function Element:calculateTextHeight()
-- Resolve font path from font family (same logic as in draw) -- Resolve font path from font family (same logic as in draw)
local fontPath = nil local fontPath = nil
if self.fontFamily then if self.fontFamily then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then
fontPath = themeToUse.fonts[self.fontFamily] fontPath = themeToUse.fonts[self.fontFamily]
else else
fontPath = self.fontFamily fontPath = self.fontFamily
end end
elseif self.themeComponent then elseif self.themeComponent then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() fontPath = self._themeManager:getDefaultFontFamily()
if themeToUse and themeToUse.fonts and themeToUse.fonts.default then
fontPath = themeToUse.fonts.default
end
end end
font = FONT_CACHE.get(self.textSize, fontPath) font = FONT_CACHE.get(self.textSize, fontPath)
else else
@@ -2658,8 +2306,6 @@ function Element:moveCursorToNextWord()
end end
end end
-- ==================== -- ====================
-- Input Handling - Selection Management -- Input Handling - Selection Management
-- ==================== -- ====================
@@ -3061,7 +2707,7 @@ function Element:_getFont()
-- Get font path from theme or element -- Get font path from theme or element
local fontPath = nil local fontPath = nil
if self.fontFamily then if self.fontFamily then
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive() local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then
fontPath = themeToUse.fonts[self.fontFamily] fontPath = themeToUse.fonts[self.fontFamily]
else else
@@ -3072,8 +2718,6 @@ function Element:_getFont()
return FONT_CACHE.getFont(self.textSize, fontPath) return FONT_CACHE.getFont(self.textSize, fontPath)
end end
-- ==================== -- ====================
-- Input Handling - Mouse Selection -- Input Handling - Mouse Selection
-- ==================== -- ====================
@@ -3250,8 +2894,6 @@ function Element:_handleTextDrag(mouseX, mouseY)
end end
end end
-- ==================== -- ====================
-- Input Handling - Keyboard Input -- Input Handling - Keyboard Input
-- ==================== -- ====================

262
modules/ThemeManager.lua Normal file
View File

@@ -0,0 +1,262 @@
--- ThemeManager.lua
--- Manages theme application, state transitions, and property resolution for Elements
--- Extracted from Element.lua as part of element-refactor-modularization task 06
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local Theme = req("Theme")
---@class ThemeManager
---@field theme string? -- Theme name to use
---@field themeComponent string? -- Component name from theme (e.g., "button", "panel")
---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled)
---@field disabled boolean -- If true, element is disabled
---@field active boolean -- If true, element is in active state (e.g., focused input)
---@field disableHighlight boolean -- If true, disable pressed highlight overlay
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges
---@field scalingAlgorithm string? -- "nearest" or "bilinear" scaling for 9-patch
---@field _element table? -- Reference to parent Element
local ThemeManager = {}
ThemeManager.__index = ThemeManager
--- Create 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
self.disabled = config.disabled or false
self.active = config.active or false
self.disableHighlight = config.disableHighlight
self.scaleCorners = config.scaleCorners
self.scalingAlgorithm = config.scalingAlgorithm
-- Internal state
self._themeState = "normal"
self._element = nil
return self
end
--- Initialize ThemeManager with parent element reference
---@param element table The parent Element
function ThemeManager:initialize(element)
self._element = element
end
--- Update theme state based on interaction state
---@param isHovered boolean Whether element is hovered
---@param isPressed boolean Whether element is pressed
---@param isFocused boolean Whether element is focused
---@param isDisabled boolean Whether element is disabled
---@return string The new theme state
function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled)
local newState = "normal"
-- State priority: disabled > active > pressed > hover > normal
if isDisabled or self.disabled then
newState = "disabled"
elseif self.active then
newState = "active"
elseif isPressed then
newState = "pressed"
elseif isHovered then
newState = "hover"
end
self._themeState = newState
return newState
end
--- Get current theme state
---@return string The current theme state
function ThemeManager:getState()
return self._themeState
end
--- Set theme state directly
---@param state string The theme state to set
function ThemeManager:setState(state)
self._themeState = state
end
--- Check if this ThemeManager has a theme component
---@return boolean
function ThemeManager:hasThemeComponent()
return self.themeComponent ~= nil
end
--- Get the theme component name
---@return string?
function ThemeManager:getThemeComponent()
return self.themeComponent
end
--- Get the theme to use (element-specific or active theme)
---@return table? The theme object or nil
function ThemeManager:getTheme()
if self.theme then
return Theme.get(self.theme)
end
return Theme.getActive()
end
--- Get the component definition from the theme
---@return table? The component definition or nil
function ThemeManager:getComponent()
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
return themeToUse.components[self.themeComponent]
end
--- Get the current state's component definition (including state-specific overrides)
---@return table? The component definition for current state or nil
function ThemeManager:getStateComponent()
local component = self:getComponent()
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
return component.states[state]
end
return component
end
--- Get property value from theme for current state
---@param property string The property name
---@return any? The property value or nil
function ThemeManager:getStyle(property)
local stateComponent = self:getStateComponent()
if not stateComponent then
return nil
end
return stateComponent[property]
end
--- Get the scaled content padding for current theme state
---@param borderBoxWidth number The element's border box width
---@param borderBoxHeight number The element's border box height
---@return table? {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,
}
end
return nil
end
--- Get contentAutoSizingMultiplier from theme
---@return number? The multiplier or nil
function ThemeManager:getContentAutoSizingMultiplier()
if not self.themeComponent then
return nil
end
local themeToUse = self:getTheme()
if not themeToUse then
return nil
end
-- 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
end
-- Fall back to theme default
if themeToUse.contentAutoSizingMultiplier then
return themeToUse.contentAutoSizingMultiplier
end
return nil
end
--- Get default font family from theme
---@return string? The font name or path, or nil
function ThemeManager:getDefaultFontFamily()
local themeToUse = self:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts["default"] then
return themeToUse.fonts["default"]
end
return nil
end
--- Set theme and component
---@param themeName string? The theme name
---@param componentName string? The component name
function ThemeManager:setTheme(themeName, componentName)
self.theme = themeName
self.themeComponent = componentName
end
--- Get scale corners multiplier
---@return number?
function ThemeManager:getScaleCorners()
return self.scaleCorners
end
--- Get scaling algorithm
---@return string?
function ThemeManager:getScalingAlgorithm()
return self.scalingAlgorithm
end
return ThemeManager